模板
<template>
  <div class="page">
    <div class="container-box">
      <div class="slider-box">
        <el-tabs v-model="activeName" @tab-click="handleClick" :before-leave="handleRest" :stretch="true">
          <el-tab-pane label="手动控制" name="first">
            <manua-control ref="sliderRef" :list="interactiveJoints" :activate-model="activateModel"
                           @sliderInput="sliderInput"/>
            <div class="btn-box">
              <el-button style="width: 120px" type="primary" @click="saveFile()">保存图片</el-button>
              <el-button style="width: 120px" type="primary" @click="handleRest()">重置</el-button>
            </div>
          </el-tab-pane>
          <el-tab-pane label="自动化配置" name="third">
            <local-control :list="interactiveJoints" @sliderInput="sliderInput" @startRotate="startRotate"></local-control>
          </el-tab-pane>
        </el-tabs>
      </div>
      <div id="container"></div>
    </div>


  </div>
</template>

<script>
import * as Three from 'three'
import {LoadingManager, MathUtils} from 'three'
import URDFLoader from 'urdf-loader'
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'
import ManuaControl from './components/manuaContror.vue'
import LocalControl from "./components/localControl/index.vue";

/**
 * 代表一个Three.js图形渲染组件
 */
export default {
  name: 'HelloWorld',
  components: {
    ManuaControl,
    LocalControl
  },
  props: {
    msg: String // 接收一个字符串类型的属性
  },
  data() {
    return {
      scene: null, // Three.js场景
      camera: null, // Three.js相机
      renderer: null, // Three.js渲染器
      mesh: null, // Three.js网格
      controls: null, // Three.js控制器
      container: null, // 容器元素
      robot: null,
      activateModel: '',// 鼠标当前选中的模型名称
      previousSelectedMesh: null,// 上一次选中的模型
      activeName: 'first',
      interactiveJoints: [], // 可操作关节列表
    }
  },
  mounted() {
    this.init(); // 组件挂载时初始化Three.js场景
    // 屏幕宽高变化 重新初始化背景
    window.addEventListener("resize", this.handleAutoCanvas);
  },
  methods: {
    handleClick() {
      this.handleRest()
    },
    /**
     * 初始化Three.js场景、相机、渲染器等
     */
    async init() {
      const Loading = this.$loading()
      this.container = document.getElementById('container');
      this.scene = new Three.Scene();
      const manager = new LoadingManager();
      const loader = new URDFLoader(manager);
      const path = './modelFile/TCR050/TCR050.urdf';
      // const path = '/modelFile/urdf/111.urdf';
      // const path = '/modelFile/urdf/fishbot_gazebo.urdf';
      // const path = '/modelFile/cart/urdf/vehicle.urdf';
      const response = await loader.loadAsync(path);
      this.robot = response;
      // 模型添加点击事件
      document.addEventListener('click', (e) => {
        this._testOnClick(e, this.robot);
      });
      // 这里需要延时，否则模型渲染出来时，模型的材质没有被保存，导致点击变色无效
      setTimeout(() => {
        let linkNameArr = []
        response.traverse((child) => {
          if (child instanceof Three.Mesh) {
            child.userData.originalMaterial = child.material.clone()
          }
          // 获取可操作关节的linkName 只要可操作的
          if (child.isMesh && child.parent.parent.name !== 'base_link') {
            linkNameArr.push(child.parent.parent.name)
          }
        });
        // 提取可操作关节并存入数组 用于渲染控制区域
        this.interactiveJoints = Object.keys(response.joints).map((jointName, index) => {
          return {
            name: '关节' + (index + 1),
            jointName,
            linkName: linkNameArr[index],
            value: 0,
            min: -180,
            max: 180,
          }
        });
        Loading.close()
      }, 1000);
      this.robot.position.x = 0;
      this.robot.position.y = 0;
      this.robot.position.z = 0;
      this.scene.add(this.robot);
      this.renderer = new Three.WebGLRenderer({antialias: true, preserveDrawingBuffer: true});
      this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
      // this.renderer.setClearColor(0xffffff,0.0);  // 设置渲染器的背景颜色为白色且透明
      this.container.appendChild(this.renderer.domElement);
      this.createLight(-20, 30, 40); // 创建光源
      this.createLight(20, -30, -40);
      this.createCamera(); // 创建相机
      this.createControls(); // 创建控制器
      this.render(); // 进行渲染
    },
    /**
     * 创建Three.js相机
     */
    createCamera() {
      this.camera = new Three.PerspectiveCamera(70, this.container.clientWidth / this.container.clientHeight, 0.01, 1000);
      this.camera.position.set(0, -1, 0);        // 设置相机位置
      this.camera.lookAt(new Three.Vector3(0, 0, 0));         // 设置相机朝向
    },
    /**
     * 创建轨道控制器
     */
    createControls() {
      this.clock = new Three.Clock(); // 创建计时器
      this.controls = new OrbitControls(this.camera, this.renderer.domElement)
      this.controls.autoRotate = false // 设置是否自动旋转
    },
    /**
     * 在场景中添加光源
     * @param {number} pos_x 光源x轴位置
     * @param {number} pos_y 光源y轴位置
     * @param {number} pos_z 光源z轴位置
     */
    createLight(pos_x, pos_y, pos_z) {
      // 添加环境光
      const ambientLight = new Three.AmbientLight(0x111111) // 创建环境光
      this.scene.add(ambientLight) // 将环境光添加到场景
      const directionLight = new Three.DirectionalLight(0xffffff)
      directionLight.position.set(pos_x, pos_y, pos_z)
      directionLight.intensity = 1.5
      this.scene.add(directionLight) // 添加方向光
    },
    /**
     * 实现渲染循环
     */
    render() {
      const delta = this.clock.getDelta(); // 获取自上次渲染以来的时间差
      this.controls.update(delta); // 更新控制器
      this.renderer.render(this.scene, this.camera); // 渲染场景
      requestAnimationFrame(this.render); // 请求下一个动画帧
    },
    // 设置各个关节的角度
    sliderInput(value, jointName, linkName) {
      // 找到要设置的关节
      this.robot?.joints[jointName].setJointValue(MathUtils.degToRad(value));
      // 防抖
      // debounce(this.updateModelsForJoint(this.robot?.joints[jointName]),3000, false)
      // 递归找到变化的模型 让其变色
      this.updateModelsForJoint(this.robot?.joints[jointName], linkName);
      // console.log('当前点击的模型名称:', name, this.robot?.joints[jointName])
    },
    // 设置各个关节的角度 只运动
    startRotate(value, jointName) {
      // 找到要设置的关节
      this.robot?.joints[jointName].setJointValue(MathUtils.degToRad(value));
      // 还原所有模型至原始颜色
      // this.restoreAllModelsToOriginal();
    },
    /**
     * 更新与特定关节关联的模型的材质和颜色。
     * @param {Object} joint - 关节对象，用于遍历模型树寻找关联模型。
     * @param {string} linkName - 关节名称，用于匹配模型的parent.parent.name。
     */
    updateModelsForJoint(joint, linkName) {
      let _this = this;

      // 递归遍历模型树，寻找与关节关联的模型
      function traverseModelTree(model) {
        // 检查当前模型是否为与关节关联的模型，如果是，则改变其材质颜色并记录相关信息
        if (model?.isMesh && model instanceof Three.Mesh && linkName === model.parent.parent.name) {
          // 还原所有模型至原始颜色
          _this.restoreAllModelsToOriginal();
          // 设置当前选中模型的材质颜色
          model.material = new Three.MeshPhysicalMaterial({
            color: '#409EFF',
            metalness: 1,
            roughness: 0.7,
          });
          // 保存当前选中模型的linkName
          _this.activateModel = model.parent.parent.name
          // 记录之前选中的模型
          _this.previousSelectedMesh = model;
        }
        // 递归检查子模型
        if (model?.children) {
          for (const item of model.children) {
            traverseModelTree(item);
          }
        }
      }

      // 开始遍历模型树
      traverseModelTree(joint);
    },
    /**
     * 当点击事件发生时，测试并处理点击交互。
     * @param {MouseEvent} event - 触发的鼠标事件。
     * @param {Object3D} object - 用于检测点击的对象。
     */
    _testOnClick(event, object) {
      const element = document.getElementById('container');
      const react = element.getBoundingClientRect()
      const width = react.width
      const height = react.height
      const top = event.clientY - react.top
      const left = event.clientX - react.left
      const mouse = new Three.Vector2(
          (left / width) * 2 - 1,
          -(top / height) * 2 + 1
      );
      // 创建光线投射器用于检测鼠标点击是否命中3D对象
      const raycaster = new Three.Raycaster();
      // 设置光线投射器的起点（相机位置）和方向（基于鼠标点击位置）
      raycaster.setFromCamera(mouse, this.camera);
      // 执行光线投射，检测是否有对象被命中
      const intersects = raycaster.intersectObject(object, true);
      // 如果命中了至少一个对象
      if (intersects.length > 0) {
        // 获取最接近的命中对象
        const selected = intersects[0].object;
        // 如果命中对象是一个网格
        if (selected instanceof Three.Mesh) {
          if (selected === this.previousSelectedMesh) {
            // console.log('重复点击')
            // 同一模型连续点击两次，恢复其原始颜色
            this.restoreOriginalColor(selected);
            this.activateModel = ''
            this.previousSelectedMesh = null
          } else {
            // 还原所有模型的颜色
            this.restoreAllModelsToOriginal();
            // 改变当前点击材质颜色
            selected.material = new Three.MeshPhysicalMaterial({
              color: '#409EFF',
              metalness: 1,
              roughness: 0.7,
            });
            // 保存linkName
            let linkName = selected.parent.parent.name
            this.activateModel = linkName
            console.log('点击的模型名称:', linkName, selected)
            // 记录当前选中的模型
            this.previousSelectedMesh = selected;
          }
          // console.log('命中',selected)
        }
      } else if (this.previousSelectedMesh) {
        // 点击区域外，还原所有模型的颜色
        // this.restoreAllModelsToOriginal();
        // this.previousSelectedMesh = null;
      }
    },
    /**
     * 恢复给定网格对象的原始颜色
     * @param {THREE.Mesh} mesh 需要恢复颜色的网格对象
     */
    restoreOriginalColor(mesh) {
      // 请确保您的网格对象在初始化时已保存其原始材质
      // 下面的代码仅作为示例，实际应用时请替换为正确的原始材质获取方式
      const originalMaterial = mesh.userData.originalMaterial; // 假设存在原始材质
      if (originalMaterial) {
        mesh.material = originalMaterial;
      }
    },
    restoreOtherModelsColor(selected) {
      console.log('恢复其他颜色', 111)
      this.robot.children.forEach((child) => {
        if (child !== selected) {
          console.log('恢复其他颜色', child)
          this.restoreOriginalColor(child);
        }
      });
    },
    restoreAllModelsToOriginal() {
      this.robot.traverse((child) => {
        if (child.isMesh) {
          this.restoreOriginalColor(child);
        }
      });
    },
    /**
     * 保存当前渲染视图为图片
     */
    saveFile() {
      var link = document.createElement("a");
      var canvas = document.getElementById('container').children[0];
      var imgData = canvas.toDataURL({format: 'image/png', quality: 1, width: 20000, height: 4000});
      var blob = this.dataURLtoBlob(imgData);
      var objurl = URL.createObjectURL(blob);
      link.download = "grid1.png";
      link.href = objurl;
      link.click();
    },

    /**
     * 将data URL转换为Blob对象
     * @param {string} dataurl - Data URL字符串
     * @returns {Blob} 转换后的Blob对象
     */
    dataURLtoBlob(dataurl) {
      var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
          bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
      while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
      }
      return new Blob([u8arr], {type: mime});
    },
    // 重置模型
    handleRest() {
      this.activateModel = '';// 鼠标当前选中的模型名称
      this.previousSelectedMesh = null;// 上一次选中的模型
      this.controls.reset();
      this.$refs['sliderRef'].rest();
      this.restoreAllModelsToOriginal();
    },
    handleAutoCanvas(){
      // 重置相机视角以适应新的屏幕宽度
      this.adjustCameraAspect();
      this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
    },
    // 调整相机视角以适应新的屏幕宽度
    adjustCameraAspect() {
      if (this.camera) {
        this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
        this.camera.updateProjectionMatrix();
      }
    }
  },
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.page {
  width: 100%;

  .container-box {
    display: flex;

    .slider-box {
      //width: 465px;
      width: 30vw;
      padding-top: 20px;

      .btn-box {
        padding: 10px;
        display: flex;
        justify-content: space-evenly;
      }
    }

    #container {
      flex: 1;
      height: 100vh;
    }
  }
}


</style>
